Skip to content

Promote to main: pool spending controls - #322

Merged
crtahlin merged 9 commits into
mainfrom
dev
Sep 2, 2026
Merged

Promote to main: pool spending controls#322
crtahlin merged 9 commits into
mainfrom
dev

Conversation

@crtahlin

@crtahlin crtahlin commented Sep 2, 2026

Copy link
Copy Markdown

Merge with a merge commit, not a squash — the merge base is currently correct and this shows only the 11 files that changed.

Three controls on what the stamp pool can give away and spend, plus the deploy wiring that makes them reachable.

Why

POST /api/v1/pool/acquire had no gate at all — absent from the protected endpoint list, so neither payment nor the free-tier header was required. Anyone able to resolve the hostname took a pre-bought batch. Measured on staging: 3,866 acquire calls in a day drove 40 replacement purchases, and an anonymous call took production's only batch during testing.

The incentives were inverted: buying a batch the slow way returns 402 without payment, while taking a pre-bought one instantly was free.

What lands

A daily allowance per origin and per size. Origin is what a browser reliably supplies, and the main consumer is a static browser app with no backend — it borrows the visitor's wallet, so there is no address to allow-list and no key it could sign with. Per size as well as per origin, because a depth-20 batch costs eight times a depth-17 one and a shared count would let a caller spend eight times its budget by asking for a larger size.

Stated plainly: a browser cannot forge another site's origin, so this does stop other websites spending the operator's postage. Any non-browser client can claim any origin, so the budget is the control and the origin only selects which budget applies. Attribution with a cap, not authorisation.

A ceiling on purchases — 10 per rolling hour across every depth and every code path, enforced inside _purchase_stamp so no caller can miss it, counted when Bee accepts the batch rather than when it becomes usable. A staging node once bought 82 batches against a target of 5 and the cause was never identified; a ceiling does not need one.

Deploy wiring. The three new settings were not written to the env files, so setting them in GitHub had no effect at all — green deploy, variable set, gateway never saw it. Exactly the propagation trap documented in CLAUDE.md, walked into an hour after writing that page.

Configuration applied with this

production staging
pool composition small=1, medium=0 small=1, medium=0
named allowance dataprovenance.app=20 dev.dataprovenance.app=20
default allowance 20 20

The pool previously held a medium, which was my error: the app asks for small, found none, and fell through to "any larger". Every request cost 0.1476 BZZ instead of 0.0184 — eight times over. At a 20/day allowance that is 2.95 BZZ/day rather than 0.37.

Verified on staging

Target {17:1}, allowance enforced, counter persisted across the request:

{"day": "2026-09-01", "used": {"https://dev.dataprovenance.app|small": 2}}

A 409 from an empty pool does not spend the caller's budget — confirmed live.

Effect on production

Anything unconfigured keeps its previous behaviour: an unset default allowance means unlimited. The values above are already set as repository variables, so they take effect with this deploy.

dataprovenance.app relies on the Origin the browser adds automatically, which is exactly what this uses — so it keeps working, now bounded at 20 small batches a day.

Full suite: 1101 passed, 25 skipped.

Nothing described how to rebuild this. The knowledge lived in scattered commit
messages and in the heads of whoever last touched the host, so if the machine
died, rebuilding would have been archaeology. #246 asked for a runbook a new
engineer could follow alone.

Written from the install that is actually running rather than from the plan, and
it says where the two diverged: #240 called for a dedicated staging VM with a
Sepolia testnet Bee, and what exists is one host with two mainnet nodes. That was
a cost decision, and it has a consequence worth stating rather than discovering —
staging spends real BZZ, and in exchange catches things a testnet cannot.

Covers VM specs and the exactly-five firewall rules, the egress check that
matters because a node which cannot dial out returns 201 for uploads that never
propagate, Bee bring-up and funding, the deploy and its runner, DNS and TLS,
monitoring, verification, and troubleshooting.

The parts that exist because they cost us something:

  BEE_NAT_ADDR must match the published port or the node reports itself Private
  with the port open, and since Bee 2.7.0 an invalid value fails startup

  the two RPC endpoints must differ — sharing one meant sharing its rate limit,
  and staging load produced 429s that broke production

  COMPOSE_PROJECT_NAME is pinned because Compose otherwise derives volume names
  from the directory path, and a deploy from elsewhere would start Bee with no
  keystore: a new wallet, the funded one orphaned

  diff the Caddyfile before overwriting — doing it blind once broke TLS

  check whether a runner already on the host serves other repositories; the one
  here also serves four, and unregistering it would have broken all of them

No secrets, keys, addresses or hostnames — only what is needed, where it is
stored, and how it reaches the process, per the issue's acceptance criteria.
Verified: zero matches for real hostnames or credential patterns.

Linked from the README's setup section, since standing up a host is a different
job from running the app locally.

Closes #246.
Add an install runbook for standing up a host from nothing
It should not have been added here, and that is my error rather than a change of
mind. The issue asked for no secrets, keys, IPs or hostnames; the document
contains none, and I treated that as the whole test for a PUBLIC repository.

It is not. A runbook for this specific install describes the topology whether or
not it names it: that both environments share one machine, the p2p and loopback
port numbers, the firewall shape, that the Bee API is unpublished but reachable
on the compose network, that a self-hosted CI runner sits on the same host, which
secrets exist, and a list of our known failure modes — including that a node
unable to dial out returns success for uploads that never store anything.

Individually dull. Together, a map of where to look.

The document itself is worth having and now lives in the private provenance
space, unchanged apart from a header recording why it is there.

Removing the file does NOT remove it from this repository's history. It was
public from 2026-08-30T19:27Z and remains reachable via the commit and the merged
pull request. Whether that warrants rewriting history is a judgement call for the
maintainers, recorded on the issue rather than decided here.

If a public install guide is wanted later it should be a different document: how
to run the software, not how this deployment is arranged.

Reopens #246 in effect — the runbook exists, in the right place.
The runbook documented OUR install, in a public repository. Wrong artifact for
the audience: our topology is not useful to anyone else, and putting it there
answered a question nobody outside had asked.

What a public repo wants is how to run the software. That is this.

Worth recording that the leak itself was smaller than it looked. Every fact the
runbook exposed was already in this repository independently — the p2p and
loopback ports in the compose files and Caddyfile, both environments side by side
in docker-compose.host.yml, the unpublished Bee API in a comment, the self-hosted
runner and secret names in deploy.yml, the reachability failure mode, and "uploads
201 without propagating" in CLAUDE.md. It aggregated rather than disclosed. That
is why removing it is a tidiness decision rather than an incident, and why
rewriting history would achieve nothing.

The genuinely useful half survives here: the failure modes, which are properties
of the software and not of our deployment. A NAT address that does not match the
published port. Two nodes sharing an RPC endpoint and therefore its rate limit.
An unpinned compose project name starting Bee with no keystore. Docker publishing
ports through its own iptables chains and bypassing ufw. A node that cannot dial
out returning 201 for uploads that never store anything. The three ways a config
change can report success and do nothing.

Everything that said "we do it this way" is gone; choices are presented as
choices. The specifics live in the private space.

Verified: zero occurrences of our hosts, ports, runner labels or addresses.

Full suite: 1083 passed, 25 skipped.
Replace the install runbook with a generic deployment guide
A staging node bought 82 batches against a target of 5, spending about 8.9 BZZ.
One mechanism was found and fixed and it accounts for five per restart, not
seventy. The rest was never identified, and the evidence is gone: the proxy was
not logging requests, the container holding the gateway's own log was replaced,
and the purchase counter resets on restart with no long-term series (#271).

So this does not try to find the cause. A ceiling does not need one — whatever
the defect, a miscounting replenish path, a restart loop, an external caller
draining the pool faster than it refills, it cannot spend past the limit.
Correctness bugs stay wrong; they stop being expensive.

STAMP_POOL_MAX_PURCHASES_PER_HOUR, default 10, across every depth. Far above any
legitimate need — a target of five, fully drained and rebought, is five — and far
below what an unbounded loop costs.

Two placement decisions:

Enforced inside _purchase_stamp rather than in the replenish loop. There are two
callers today, the scheduled check and the immediate replenishment after an
acquire, and the guard has to hold for whatever is added next. A limit the caller
must remember to ask about is not a limit.

Counted when Bee accepts the batch, not when it becomes usable. The money is
spent at acceptance. Counting only usable batches would let a run of unusable
ones spend without bound while appearing to buy nothing — which is close to the
shape of the original incident.

Refusals are logged at error level and surfaced in GET /api/v1/pool/status, since
a pool silently declining to buy looks exactly like a pool that needs nothing.

Four tests: the ceiling holds under repeated calls, an unusable purchase still
counts, the refusal reaches pool status rather than only the log, and the window
rolls so an old burst does not jam the pool permanently.

Full suite: 1087 passed, 25 skipped.
* Give the stamp pool a daily budget per calling origin

POST /api/v1/pool/acquire had no gate at all. It is absent from
PROTECTED_ENDPOINTS, so it required neither payment nor the free-tier header:
anyone able to resolve the hostname took a pre-bought batch. Measured on
staging, 3,866 acquire calls in a day drove 40 replacement purchases, and an
anonymous call took production's only batch during testing.

The incentives were inverted. Buying a batch the slow way returns 402 without
payment; taking a pre-bought one instantly was free. The premium path was the
free one.

An allowance keyed on Origin fits what is knowable here. The main consumer is a
static browser app with no backend: it borrows the visitor's MetaMask, so there
is no single address to allow-list and no server-held key it could sign with.
Origin is the only thing a browser reliably supplies.

What that is worth, stated plainly because it would otherwise be overread: a
browser sets Origin itself and a page cannot forge another site's, so this does
stop other WEBSITES spending the operator's postage. Anything that is not a
browser can claim any origin, so the BUDGET protects the pool and the origin only
selects which budget applies. A forged origin consumes that origin's allowance
and no more. Attribution with a cap, not authorisation; require a payment or a
signature if you need to know who is spending.

The 429 carries a message written to be shown to a person rather than logged.
The caller is a browser app whose user has never heard of a postage batch, so it
gives the reset time and the direct-purchase alternative — what to do, not only
that something failed.

Consumed only after a batch is actually released, so an empty pool does not cost
the caller a day's budget. Counters persist across restarts, because an
in-memory count would hand out a fresh allowance on every restart, which is the
shape of the incident this exists to prevent.

POOL_DEFAULT_DAILY_ALLOWANCE defaults to -1, unlimited, matching the behaviour
before this existed. Deploying therefore changes nothing until allowances are
configured deliberately: dataprovenance-app calls this endpoint today and a limit
arriving unannounced would break it (dataprovenance-app#126).

Ten tests, including that a differently-written form of the same origin does not
get a second budget, that a restart does not reset the count, and that a refused
acquire does not spend the allowance.

Full suite: 1093 passed, 25 skipped.

* Offer the paid route in the exhausted-allowance message, accurately

The message told the caller to purchase a stamp "directly" without saying it is
payable, and the test only checked that some alternative existed. It now names
x402 and the endpoint, and the test asserts both — plus that the allowance figure
in the text comes from config rather than being written into the string.

It deliberately does NOT offer to sell pool access, which would be the obvious
thing to say and is not true. Paying for a pooled batch is not possible: the
payment gate is a FastAPI dependency, and a caller with no payment header and no
X-Payment-Mode: free receives 402 before the handler runs. Every current caller
sends neither, so attaching it to this endpoint would break them — including
dataprovenance-app, which is the reason the endpoint was left open.

So the honest offer is the one that works today: POST /api/v1/stamps/ is already
payable with x402 and buys a batch outright. The message says it takes about a
minute to become usable, unlike a pooled one, so nobody is surprised by the
latency the pool exists to remove.

Paid pool access is #67 and is blocked on dataprovenance-app#126.
The allowance counted batches per origin regardless of size. Sizes differ in
cost by powers of two — a depth-20 batch costs eight times a depth-17 one — so a
caller could spend eight times its intended budget simply by asking for a larger
size, without ever exceeding a limit. An allowance of 20 meant "20 small" or
"20 medium" depending on what was asked for, which are not the same amount of
money.

The budget key is now (origin, size), and the limit applies to each size
separately. Resolving the requested size moved above the budget check, since the
check now needs it; the duplicate resolution further down is removed rather than
left to drift.

The 429 names the size as well as the count, so "the daily free allowance of 20
small stamps" says which budget was exhausted — a caller told only that "the
allowance" ran out cannot tell that a different size is still available.

Four tests: sizes hold separate budgets, a larger size cannot borrow the smaller
one's, the limit applies to each size rather than being shared, and origin and
size are both reported rather than conflated.

Full suite: 1101 passed, 25 skipped.
Three settings added recently — the hourly purchase ceiling, and the two
controlling daily allowances — were never added to deploy.yml, which writes the
env files from repository variables. So setting them in GitHub had no effect
whatsoever: the variable existed, the deploy succeeded, and the running gateway
never saw it.

This is precisely the propagation trap documented in CLAUDE.md, and I walked into
it while configuring the allowance an hour after writing that page. Worth
recording rather than quietly fixing: the failure is invisible from the GitHub
side, because the variable is set and the deploy is green.

POOL_DAILY_ALLOWANCES has no default, so an unset variable yields an empty string
and no named allowances, which is the intended off state.
POOL_DEFAULT_DAILY_ALLOWANCE defaults to -1 (unlimited) and
STAMP_POOL_MAX_PURCHASES_PER_HOUR to 10, matching the code defaults, so a host
that has configured neither behaves exactly as before.

Added to both the staging and production blocks. Full suite: 1101 passed.
@crtahlin
crtahlin merged commit 22f058c into main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant